Outline of JVM Concepts - 01. 内存模型

  • <深入理解Java虚拟机> 第二版, 第二章摘录 + 笔记.

运行时数据区

总体概念示意:

PC - 计数器

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native 方法,这个计数器值则为空(Undefined)。

异常说明

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

JVM虚拟机栈

每个方法在执行的同时都会创建一个栈帧(Stack Frame )用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈帧概念后面详细说明. 通过-Xss设置栈的最大长度.

局部变量表

如果有人简单将JVM内存划分为堆栈, 那么栈即指的是局部变量表. long,double占2个Slot, 其余占一个. (先别管什么是Slot).

  • returnAddress 指向一条字节码指令的地址
  • reference 可能是指向对象起始地址的引用指针, 也可能指向对象句柄或者其他与对象相关的位置.

异常说明

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

限制栈容量, 通过无线递归即可实现. 在作者的实验中:

在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

本地方法栈

和JVM虚拟机栈类似. 区别为本地方法栈为虚拟机使用到的Native方法服务. 异常和虚拟机栈相同.

Heap

此区域的唯一目的就是存放对象实例.

虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配 [1] ,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换 [2] 优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

从GC角度, 和内存分配角度可以进行更细致分类, 但是与实际存储内容无关.

调整Heap大小参数: -Xmx, -Xms

异常说明

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常.

通过创建大数组和大量对象实现溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[]args){
List<OOMObject>list=new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}

方法区

存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫Non-Heap,目的应该是与Java堆区分开.

有人将方法区称为PermGen, 是因为从HotSpot GC角度来讲, 使用PermGen来实现方法区, 而其他虚拟机没有这个概念. 而实际上JDK8中已经去掉了PermGen. 在后面的GC部分说明.

运行时常量池

Runtime Constant Pool是方法区的一部分.

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

但是运行时常量池中的数据不一定来自编译好的Class文件, 也可能在运行时中加入, 比如String.intern.

对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。

从HotSpot的GC角度来讲, 在JDK7之前, 常量池放在PermGen中, 而7中则已经移除了PermGen. (GC部分详细说明).

异常说明

可能抛出OutOfMemoryError. 首先限制PermGen大小: -XX:PermSize=10M -XX:MaxPermSize=10M. 然后:

  • 在JDK6以及之前, 可以通过限制PermGen并无限对字符串进行intern来实现溢出.
  • 也可以通过CGLib等工具无限生成类来实现.

关于常量池的有趣栗子

1
2
3
4
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);

这段代码在JDK 1.6中运行,会得到两个false,而在JDK 1.7中运行,会得到一个true和一个false。

产生差异的原因是:
在JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。
而JDK 1.7(以及部分其他虚拟机,例如JRockit)的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。对str2比较返回false是因为”java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。

HotSpot对象

对象内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头
    • 对象自身运行时数据
    • 类型指针, 用于确定对象是哪个类的实例. 但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身. (后面说明). 另外,如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。
  • 实例数据, 本身的字段或者是继承的字段都要记录. 字段顺序受VM分配策略参数(FieldsAllocationStyle)和字段在代码中的定义顺序影响. 详见书中说明.
  • 对齐填充, 并不一定存在. 。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象访问定位

两种方式

  • 使用句柄访问. Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息.
  • 使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址.

优劣对比:

  • 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾
    收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
  • 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销.

HotSpot使用的是第二种, 直接指针.


See Also